/*
 * #%L
 * Alfresco Repository
 * %%
 * Copyright (C) 2005 - 2016 Alfresco Software Limited
 * %%
 * This file is part of the Alfresco software. 
 * If the software was purchased under a paid Alfresco license, the terms of 
 * the paid license agreement will prevail.  Otherwise, the software is 
 * provided under the following open source license terms:
 * 
 * Alfresco is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * Alfresco is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser General Public License for more details.
 * 
 * You should have received a copy of the GNU Lesser General Public License
 * along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
 * #L%
 */
package org.alfresco.repo.security.person;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.context.ApplicationEvent;
import org.springframework.extensions.surf.util.AbstractLifecycleBean;

import org.alfresco.model.ContentModel;
import org.alfresco.repo.batch.BatchProcessWorkProvider;
import org.alfresco.repo.batch.BatchProcessor;
import org.alfresco.repo.batch.BatchProcessor.BatchProcessWorkerAdaptor;
import org.alfresco.repo.security.authentication.AuthenticationUtil;
import org.alfresco.repo.security.authentication.AuthenticationUtil.RunAsWork;
import org.alfresco.repo.tenant.Tenant;
import org.alfresco.repo.tenant.TenantAdminService;
import org.alfresco.repo.tenant.TenantService;
import org.alfresco.repo.tenant.TenantUtil;
import org.alfresco.repo.tenant.TenantUtil.TenantRunAsWork;
import org.alfresco.repo.transaction.RetryingTransactionHelper;
import org.alfresco.repo.transaction.RetryingTransactionHelper.RetryingTransactionCallback;
import org.alfresco.service.cmr.model.FileExistsException;
import org.alfresco.service.cmr.model.FileFolderService;
import org.alfresco.service.cmr.model.FileNotFoundException;
import org.alfresco.service.cmr.repository.NodeRef;
import org.alfresco.service.cmr.repository.NodeService;
import org.alfresco.service.cmr.repository.Path;
import org.alfresco.service.cmr.repository.datatype.DefaultTypeConverter;
import org.alfresco.service.cmr.security.AuthorityService;
import org.alfresco.service.cmr.security.AuthorityType;
import org.alfresco.service.cmr.security.NoSuchPersonException;
import org.alfresco.service.cmr.security.PersonService;
import org.alfresco.service.namespace.QName;
import org.alfresco.service.transaction.TransactionService;
import org.alfresco.util.VmShutdownListener;

/**
 * Called on startup to move (synchronise) home folders to the preferred location defined by their {@link HomeFolderProvider2} or extend the now depreciated {@link AbstractHomeFolderProvider}. Only users that use a HomeFolderProvider2 that don't provide a shared home folder (all user are given the same home folder) will be moved. This allows existing home directories to be moved to reflect changes in policy related to the location of home directories. Originally created for ALF-7797 which related to the need to move large numbers of existing home directories created via an LDAP import into a hierarchical folder structure with fewer home folder in each.
 * <p>
 * 
 * By default no action is taken unless the the global property {@code home_folder_provider_synchronizer.enabled=true}.
 * <p>
 * 
 * The home folders for internal users (such as {@code admin} and {@code
 * guest}) that use {@code guestHomeFolderProvider} or {@code
 * bootstrapHomeFolderProvider} are not moved, nor are any users that use HomeFolderProviders create shared home folders (all user are given the same home folder).
 * 
 * It is also possible change the HomeFolderProvider used by all other users by setting the global property {@code home_folder_provider_synchronizer.override_provider=<providerBeanName>}.
 * <p>
 * 
 * <b>Warning:</b> The LDAP synchronise process overwrites the home folder provider property. This is not an issue as long as the root path of the overwriting provider is the same as the overwritten provider or is not an ancestor of any of the existing home folders. This is important because the root directory value is used by this class to tidy up empty 'parent' folders under the root when a home folders are moved elsewhere. If you have any concerns that this may not be true, set the global property {@code home_folder_provider_synchronizer.keep_empty_parents=true} and tidy up any empty folders manually. Typically users created by the LDAP sync process are all placed under the same root folder so there will be no parent folders anyway.
 * <p>
 * 
 * @author Alan Davis
 */
public class HomeFolderProviderSynchronizer extends AbstractLifecycleBean
{
    private static final Log logger = LogFactory.getLog(HomeFolderProviderSynchronizer.class);
    private static final Log batchLogger = LogFactory.getLog(HomeFolderProviderSynchronizer.class + ".batch");

    private static final String GUEST_HOME_FOLDER_PROVIDER = "guestHomeFolderProvider";
    private static final String BOOTSTRAP_HOME_FOLDER_PROVIDER = "bootstrapHomeFolderProvider";

    private final TransactionService transactionService;
    private final AuthorityService authorityService;
    private final PersonService personService;
    private final FileFolderService fileFolderService;
    private final NodeService nodeService;
    private final PortableHomeFolderManager homeFolderManager;
    private final TenantAdminService tenantAdminService;

    private boolean enabled;
    private String overrideHomeFolderProviderName;
    private boolean keepEmptyParents;

    public HomeFolderProviderSynchronizer(
            TransactionService transactionService,
            AuthorityService authorityService, PersonService personService,
            FileFolderService fileFolderService, NodeService nodeService,
            PortableHomeFolderManager homeFolderManager,
            TenantAdminService tenantAdminService)
    {
        this.transactionService = transactionService;
        this.authorityService = authorityService;
        this.personService = personService;
        this.fileFolderService = fileFolderService;
        this.nodeService = nodeService;
        this.homeFolderManager = homeFolderManager;
        this.tenantAdminService = tenantAdminService;
    }

    public void setEnabled(String enabled)
    {
        this.enabled = "true".equalsIgnoreCase(enabled);
    }

    private boolean enabled()
    {
        return enabled;
    }

    public void setOverrideHomeFolderProviderName(String overrideHomeFolderProviderName)
    {
        this.overrideHomeFolderProviderName = overrideHomeFolderProviderName;
    }

    private String getOverrideHomeFolderProviderName()
    {
        return overrideHomeFolderProviderName;
    }

    public void setKeepEmptyParents(String keepEmptyParents)
    {
        this.keepEmptyParents = "true".equalsIgnoreCase(keepEmptyParents);
    }

    private boolean keepEmptyParents()
    {
        return keepEmptyParents;
    }

    @Override
    protected void onShutdown(ApplicationEvent event)
    {
        // do nothing
    }

    @Override
    protected void onBootstrap(ApplicationEvent event)
    {
        if (enabled())
        {
            final String overrideProviderName = getOverrideHomeFolderProviderName();

            // Scan users in default and each Tenant

            scanPeople(AuthenticationUtil.getSystemUserName(), TenantService.DEFAULT_DOMAIN, overrideProviderName);

            if (tenantAdminService.isEnabled())
            {
                List<Tenant> tenants = tenantAdminService.getAllTenants();
                for (Tenant tenant : tenants)
                {
                    if (tenant.isEnabled())
                    {
                        final String tenantDomain = tenant.getTenantDomain();
                        TenantUtil.runAsSystemTenant(new TenantRunAsWork<Object>() {
                            public NodeRef doWork() throws Exception
                            {
                                scanPeople(AuthenticationUtil.getSystemUserName(), tenantDomain, overrideProviderName);
                                return null;
                            }
                        }, tenantDomain);

                    }
                }
            }
        }
    }

    /**
     * Scans all {@code person} people objects and checks their home folder is located according to the person's home folder provider preferred default location.
     * 
     * @param systemUserName
     *            String the system user name with the tenant-specific ID attached.
     * @param tenantDomain
     *            String name of the tenant domain. Used to restrict the which people are processed.
     * @param overrideProvider
     *            String the bean name of a HomeFolderProvider to be used in place of the all home folders existing providers. If {@code null} the existing provider is used.
     */
    private void scanPeople(final String systemUserName, final String tenantDomain, final String overrideProvider)
    {
        /* To avoid problems with existing home folders that are located in locations that will be needed by 'parent' folders, we need a 4 phase process. Consider the following user names and required structure. There would be a problem with the username 'ab'.
         * 
         * abc --> ab/abc def /abd abd /ab ab de/def
         * 
         * 1. Record which parent folders are needed 2. Move any home folders which overlap with parent folders to a temporary folder 3. Create parent folder structure. Done in a single thread before the move of home folders to avoid race conditions 4. Move home folders if required
         * 
         * Alternative approaches are possible, but the above has the advantage that nodes are not moved if they are already in their preferred location.
         * 
         * Also needed to change the case of parent folders. */

        // Using authorities rather than Person objects as they are much lighter
        final Set<String> authorities = getAllAuthoritiesInTxn(systemUserName);
        final ParentFolderStructure parentFolderStructure = new ParentFolderStructure();
        final Map<NodeRef, String> tmpFolders = new HashMap<NodeRef, String>();

        // Define the phases
        final String createParentFoldersPhaseName = "createParentFolders";
        final String moveFolderThatClashesPhaseName = "moveHomeFolderThatClashesWithParentFolderStructure";
        RunAsWorker[] workers = new RunAsWorker[]{
                new RunAsWorker(systemUserName, tenantDomain, "calculateParentFolderStructure") {
                    @Override
                    public void doWork(NodeRef person) throws Exception
                    {
                        calculateParentFolderStructure(
                                parentFolderStructure, person, overrideProvider);
                    }
                },

                new RunAsWorker(systemUserName, tenantDomain, moveFolderThatClashesPhaseName) {
                    @Override
                    public void doWork(NodeRef person) throws Exception
                    {
                        moveHomeFolderThatClashesWithParentFolderStructure(
                                parentFolderStructure, tmpFolders, person, overrideProvider);
                    }
                },

                new RunAsWorker(systemUserName, tenantDomain, createParentFoldersPhaseName) {
                    @Override
                    public void doWork(NodeRef person) throws Exception
                    {
                        createParentFolders(person, overrideProvider);
                    }
                },

                new RunAsWorker(systemUserName, tenantDomain, "moveHomeFolderIfRequired") {
                    @Override
                    public void doWork(NodeRef person) throws Exception
                    {
                        moveHomeFolderIfRequired(person, overrideProvider);
                    }
                }
        };

        // Run the phases
        for (RunAsWorker worker : workers)
        {
            String name = worker.getName();

            if (logger.isInfoEnabled())
            {
                logger.info("  -- " +
                        (TenantService.DEFAULT_DOMAIN.equals(tenantDomain) ? "" : tenantDomain + " ") +
                        name + " --");
            }

            int threadCount = (name.equals(createParentFoldersPhaseName) || name.equals(moveFolderThatClashesPhaseName)) ? 1 : 2;
            int peoplePerTransaction = 20;

            // Use 2 threads, 20 person objects per transaction. Log every 100 entries.
            BatchProcessor<NodeRef> processor = new BatchProcessor<NodeRef>(
                    "HomeFolderProviderSynchronizer",
                    transactionService.getRetryingTransactionHelper(),
                    new WorkProvider(authorities),
                    threadCount, peoplePerTransaction,
                    null,
                    batchLogger, 100);
            processor.processLong(worker, true);
            if (processor.getTotalErrorsLong() > 0)
            {
                logger.info("  -- Give up after error --");
                break;
            }
        }
    }

    // Can only use authorityService.getAllAuthorities(...) in a transaction.
    private Set<String> getAllAuthoritiesInTxn(final String systemUserName)
    {
        return AuthenticationUtil.runAs(new RunAsWork<Set<String>>() {
            public Set<String> doWork() throws Exception
            {
                RetryingTransactionHelper txnHelper = transactionService.getRetryingTransactionHelper();
                RetryingTransactionCallback<Set<String>> restoreCallback = new RetryingTransactionCallback<Set<String>>() {
                    public Set<String> execute() throws Exception
                    {
                        // Returns a sorted set (using natural ordering) rather than a hashCode
                        // so that it is more obvious what the order is for processing users.
                        Set<String> result = new TreeSet<String>();
                        result.addAll(authorityService.getAllAuthorities(AuthorityType.USER));
                        return result;
                    }
                };
                return txnHelper.doInTransaction(restoreCallback, false, true);
            }
        }, systemUserName);
    }

    /**
     * Work out the preferred parent folder structure so we will be able to work out if any existing home folders clash.
     */
    private ParentFolderStructure calculateParentFolderStructure(
            final ParentFolderStructure parentFolderStructure,
            NodeRef person, String overrideProviderName)
    {
        new HomeFolderHandler(person, overrideProviderName) {
            @Override
            protected void handleNotInPreferredLocation()
            {
                recordParentFolder();
            }

            @Override
            protected void handleInPreferredLocation()
            {
                recordParentFolder();
            }

            private void recordParentFolder()
            {
                parentFolderStructure.recordParentFolder(root, preferredPath);
            }
        }.doWork();

        return parentFolderStructure;
    }

    /**
     * Move any home folders (to a temporary folder) that clash with the new parent folder structure.
     */
    private void moveHomeFolderThatClashesWithParentFolderStructure(
            final ParentFolderStructure parentFolderStructure,
            final Map<NodeRef, String> tmpFolders,
            NodeRef person, String overrideProviderName)
    {
        new HomeFolderHandler(person, overrideProviderName) {
            @Override
            protected void handleNotInPreferredLocation()
            {
                moveToTmpIfClash();
            }

            @Override
            protected void handleInPreferredLocation()
            {
                moveToTmpIfClash();
            }

            private void moveToTmpIfClash()
            {
                if (parentFolderStructure.clash(root, actualPath))
                {
                    String tmpFolder = getTmpFolderName(root);
                    preferredPath = new ArrayList<String>();
                    preferredPath.add(tmpFolder);
                    preferredPath.addAll(actualPath);

                    // - providerName parameter is set to null as we don't want the
                    // "homeFolderProvider" reset
                    moveHomeFolder(person, homeFolder, root, preferredPath, originalRoot,
                            null, originalProviderName, actualPath);
                }
            }

            private String getTmpFolderName(NodeRef root)
            {
                synchronized (tmpFolders)
                {
                    String tmpFolder = tmpFolders.get(root);
                    if (tmpFolder == null)
                    {
                        tmpFolder = createTmpFolderName(root);
                        tmpFolders.put(root, tmpFolder);
                    }
                    return tmpFolder;
                }
            }

            private String createTmpFolderName(NodeRef root)
            {
                // Try a few times but then give up.
                String temporary = "Temporary-";
                int from = 1;
                int to = 100;
                for (int i = from; i <= to; i++)
                {
                    String tmpFolderName = temporary + i;
                    if (fileFolderService.searchSimple(root, tmpFolderName) == null)
                    {
                        fileFolderService.create(root, tmpFolderName, ContentModel.TYPE_FOLDER);
                        return tmpFolderName;
                    }
                }
                String msg = "Unable to create a temporary " +
                        "folder into which home folders will be moved. " +
                        "Tried creating " + temporary + from + " .. " + temporary + to +
                        ". Remove these folders and try again.";
                logger.error("     # " + msg);
                throw new PersonException(msg);
            }
        }.doWork();
    }

    /**
     * Creates the new home folder structure, before we move home folders so that we don't have race conditions that result in unnecessary retries.
     * 
     * @param person
     *            person nodeRef
     * @param overrideProviderName
     *            override provider name
     */
    private void createParentFolders(NodeRef person, String overrideProviderName)
    {
        // We could short cut this process and build all the home folder from the
        // ParentFolderStructure in the calling method, but that would complicate
        // the code a little more and might result in transaction size problems.
        // For now lets loop through all the person objects.

        new HomeFolderHandler(person, overrideProviderName) {
            @Override
            protected void handleNotInPreferredLocation()
            {
                createNewParentIfRequired(root, preferredPath);
            }

            @Override
            protected void handleInPreferredLocation()
            {
                // do nothing
            }
        }.doWork();
    }

    /**
     * If the home folder has been created but is not in its preferred location, the home folder is moved. Empty parent folder's under the old root are only removed if the old root is known and {@code home_folder_provider_synchronizer.keep_empty_parents=true} has not been set.
     * 
     * @param person
     *            Person to be checked.
     * @param overrideProviderName
     *            String name of a provider to use in place of the one currently used. Ignored if {@code null}.
     */
    private void moveHomeFolderIfRequired(NodeRef person, String overrideProviderName)
    {
        new HomeFolderHandler(person, overrideProviderName) {
            @Override
            protected void handleNotInPreferredLocation()
            {
                moveHomeFolder(person, homeFolder, root, preferredPath, originalRoot,
                        providerName, originalProviderName, actualPath);
            }

            @Override
            protected void handleInPreferredLocation()
            {
                if (logger.isInfoEnabled())
                {
                    logger.info("     # " + toPath(actualPath) + " is already in preferred location.");
                }
            }

            @Override
            protected void handleSharedHomeProvider()
            {
                if (logger.isInfoEnabled())
                {
                    logger.info("     # " + userName + " " + providerName + " creates shared home folders - These are not moved.");
                }
            }

            @Override
            protected void handleOriginalSharedHomeProvider()
            {
                if (logger.isInfoEnabled())
                {
                    logger.info("     # " + userName + " Original " + originalProviderName + " creates shared home folders - These are not moved.");
                }
            }

            @Override
            protected void handleRootOrAbove()
            {
                if (logger.isInfoEnabled())
                {
                    logger.info("     # " + userName + " has a home folder that is the provider's root directory (or is above it). " +
                            "This is normally for users that origanally had an internal provider or a provider that uses " +
                            "shared home folders - These are not moved.");
                }
            }

            @Override
            protected void handleNotAHomeFolderProvider2()
            {
                if (logger.isInfoEnabled())
                {
                    logger.info("     # " + userName + " " + providerName + " for is not a HomeFolderProvider2.");
                }
            }

            @Override
            protected void handleSpecialHomeFolderProvider()
            {
                if (logger.isInfoEnabled())
                {
                    logger.info("     # " + userName + " Original " + originalProviderName + " is an internal type - These are not moved.");
                }
            }

            @Override
            protected void handleHomeFolderNotSet()
            {
                if (logger.isInfoEnabled())
                {
                    logger.info("     # " + userName + " Home folder is not set - ignored");
                }
            }
        }.doWork();
    }

    /**
     * @return a String for debug a folder list.
     */
    private String toPath(List<String> folders)
    {
        return toPath(folders, (folders == null) ? 0 : folders.size() - 1);
    }

    private String toPath(List<String> folders, int depth)
    {
        StringBuilder sb = new StringBuilder("");
        if (folders != null)
        {
            if (folders.isEmpty())
            {
                sb.append('.');
            }
            else
            {
                for (String folder : folders)
                {
                    if (sb.length() > 0)
                    {
                        sb.append('/');
                    }
                    sb.append(folder);
                    if (depth-- <= 0)
                    {
                        break;
                    }
                }
            }
        }
        else
        {
            sb.append("<notUnderSameRoot>");
        }
        return sb.toString();
    }

    private String toPath(NodeRef root, NodeRef leaf)
    {
        StringBuilder sb = new StringBuilder("");
        List<String> path = getRelativePath(root, leaf);
        if (path != null)
        {
            if (path.isEmpty())
            {
                sb.append('.');
            }
            else
            {
                for (String folder : path)
                {
                    if (sb.length() > 0)
                    {
                        sb.append('/');
                    }
                    sb.append(folder);
                }
            }
        }
        else
        {
            sb.append("<notUnderSameRoot>");
        }
        return sb.toString();
    }

    /**
     * @return the relative 'path' (a list of folder names) of the {@code homeFolder} from the {@code root} or {@code null} if the homeFolder is not under the root or is the root. An empty list is returned if the homeFolder is the same as the root or the root is below the homeFolder.
     */
    private List<String> getRelativePath(NodeRef root, NodeRef homeFolder)
    {
        if (root == null || homeFolder == null)
        {
            return null;
        }

        if (root.equals(homeFolder))
        {
            return Collections.emptyList();
        }

        Path rootPath = nodeService.getPath(root);
        Path homeFolderPath = nodeService.getPath(homeFolder);
        int rootSize = rootPath.size();
        int homeFolderSize = homeFolderPath.size();
        if (rootSize >= homeFolderSize)
        {
            return Collections.emptyList();
        }

        // Check homeFolder is under root
        for (int i = 0; i < rootSize; i++)
        {
            if (!rootPath.get(i).equals(homeFolderPath.get(i)))
            {
                return null;
            }
        }

        // Build up path of sub folders
        List<String> path = new ArrayList<String>();
        for (int i = rootSize; i < homeFolderSize; i++)
        {
            Path.Element element = homeFolderPath.get(i);
            if (!(element instanceof Path.ChildAssocElement))
            {
                return null;
            }
            QName folderQName = ((Path.ChildAssocElement) element).getRef().getQName();
            path.add(folderQName.getLocalName());
        }
        return path;
    }

    /**
     * Move an existing home folder from one location to another, removing empty parent folders and reseting homeFolder and homeFolderProvider properties.
     */
    private void moveHomeFolder(NodeRef person, NodeRef homeFolder, NodeRef root,
            List<String> preferredPath, NodeRef oldRoot, String providerName,
            String originalProviderName, List<String> actualPath)
    {
        try
        {
            // Create the new parent folder (if required)
            // Code still here for completeness, but should be okay
            // as the temporary folder will have been created and any
            // parent folders should have been created.
            NodeRef newParent = createNewParentIfRequired(root, preferredPath);

            // If the preferred home folder already exists, append "-N"
            homeFolderManager.modifyHomeFolderNameIfItExists(root, preferredPath);
            String homeFolderName = preferredPath.get(preferredPath.size() - 1);

            // Get the old parent before we move anything.
            NodeRef oldParent = nodeService.getPrimaryParent(homeFolder).getParentRef();

            // Log action
            if (logger.isInfoEnabled())
            {
                logger.info("     mv " + toPath(actualPath) +
                        " " + toPath(preferredPath) +
                        ((providerName != null && !providerName.equals(originalProviderName))
                                ? "    # AND reset provider to " + providerName
                                : ""));
            }

            // Perform the move
            homeFolder = fileFolderService.move(homeFolder, newParent,
                    homeFolderName).getNodeRef();

            // Reset the homeFolder property
            nodeService.setProperty(person, ContentModel.PROP_HOMEFOLDER, homeFolder);

            // Change provider name
            if (providerName != null && !providerName.equals(originalProviderName))
            {
                nodeService.setProperty(person,
                        ContentModel.PROP_HOME_FOLDER_PROVIDER, providerName);
            }

            // Tidy up
            removeEmptyParentFolders(oldParent, oldRoot);
        }
        catch (FileExistsException e)
        {
            String message = "mv " + toPath(actualPath) + " " + toPath(preferredPath) +
                    " failed as the target already existed.";
            logger.error("     # " + message);
            throw new PersonException(message);
        }
        catch (FileNotFoundException e)
        {
            // This should not happen unless there is a coding error
            String message = "mv " + toPath(actualPath) + " " + toPath(preferredPath) +
                    " failed as source did not exist.";
            logger.error("  " + message);
            throw new PersonException(message);
        }
    }

    private NodeRef createNewParentIfRequired(NodeRef root, List<String> homeFolderPath)
    {
        NodeRef parent = root;
        int len = homeFolderPath.size() - 1;
        for (int i = 0; i < len; i++)
        {
            String pathElement = homeFolderPath.get(i);
            NodeRef nodeRef = nodeService.getChildByName(parent,
                    ContentModel.ASSOC_CONTAINS, pathElement);
            String path = toPath(homeFolderPath, i);
            if (nodeRef == null)
            {
                if (logger.isInfoEnabled())
                {
                    logger.info("     mkdir " + path);
                }
                parent = fileFolderService.create(parent, pathElement,
                        ContentModel.TYPE_FOLDER).getNodeRef();
            }
            else
            {
                // Throw our own FileExistsException before we get an
                // exception when we cannot create a sub-folder under
                // a non folder that marks the transaction rollback, as
                // there is no point trying again.
                if (!fileFolderService.getFileInfo(nodeRef).isFolder())
                {
                    if (logger.isErrorEnabled())
                    {
                        logger.error("     # cannot create folder " + path +
                                " as content with the same name exists. " +
                                "Move the content and try again.");
                    }
                    throw new FileExistsException(parent, path);
                }

                parent = nodeRef;
            }
        }
        return parent;
    }

    /**
     * Removes the parent folder if it is empty and its parents up to but not including the root.
     */
    private void removeEmptyParentFolders(NodeRef parent, NodeRef root)
    {
        // Parent folders we have created don't have an owner, were as
        // home folders do, hence the 3rd test (just in case) as we really
        // don't want to delete empty home folders.
        if (root != null &&
                !keepEmptyParents() &&
                nodeService.getProperty(parent, ContentModel.PROP_OWNER) == null)
        {
            // Do nothing if root is not an ancestor of parent.
            NodeRef nodeRef = parent;
            while (!root.equals(nodeRef))
            {
                if (nodeRef == null)
                {
                    return;
                }
                nodeRef = nodeService.getPrimaryParent(nodeRef).getParentRef();
            }

            // Remove any empty nodes.
            while (!root.equals(parent))
            {
                nodeRef = parent;
                parent = nodeService.getPrimaryParent(parent).getParentRef();

                if (!nodeService.getChildAssocs(nodeRef).isEmpty())
                {
                    return;
                }
                if (logger.isInfoEnabled())
                {
                    logger.info("       rm " + toPath(root, nodeRef));
                }
                nodeService.deleteNode(nodeRef);
            }
        }
    }

    // BatchProcessWorkProvider returns batches of 100 person objects from lightweight authorities.
    private class WorkProvider implements BatchProcessWorkProvider<NodeRef>
    {
        private static final int BATCH_SIZE = 100;

        private final VmShutdownListener vmShutdownLister = new VmShutdownListener("getHomeFolderProviderSynchronizerWorkProvider");
        private final Iterator<String> iterator;
        private final int size;

        public WorkProvider(Set<String> authorities)
        {
            iterator = authorities.iterator();
            size = authorities.size();
        }

        @Override
        public synchronized int getTotalEstimatedWorkSize()
        {
            return size;
        }

        @Override
        public synchronized long getTotalEstimatedWorkSizeLong()
        {
            return size;
        }

        @Override
        public synchronized Collection<NodeRef> getNextWork()
        {
            if (vmShutdownLister.isVmShuttingDown())
            {
                return Collections.emptyList();
            }

            RetryingTransactionHelper txnHelper = transactionService.getRetryingTransactionHelper();
            RetryingTransactionCallback<Collection<NodeRef>> restoreCallback = new RetryingTransactionCallback<Collection<NodeRef>>() {
                public Collection<NodeRef> execute() throws Exception
                {
                    Collection<NodeRef> results = new ArrayList<NodeRef>(BATCH_SIZE);
                    while (results.size() < BATCH_SIZE && iterator.hasNext())
                    {
                        String userName = iterator.next();
                        try
                        {
                            NodeRef person = personService.getPerson(userName, false);
                            results.add(person);
                        }
                        catch (NoSuchPersonException e)
                        {
                            if (logger.isTraceEnabled())
                            {
                                logger.trace("The user " + userName + " no longer exists - ignored.");
                            }
                        }
                    }
                    return results;
                }
            };
            return txnHelper.doInTransaction(restoreCallback, false, true);
        }
    }

    // BatchProcessWorker that runs work as another user.
    private abstract class RunAsWorker extends BatchProcessWorkerAdaptor<NodeRef>
    {
        @Override
        public void beforeProcess() throws Throwable
        {
            AuthenticationUtil.pushAuthentication();
            AuthenticationUtil.setFullyAuthenticatedUser(userName);
        }

        @Override
        public void afterProcess() throws Throwable
        {
            AuthenticationUtil.popAuthentication();
        }

        final String userName;
        final String tenantDomain;
        final String name;

        public RunAsWorker(String userName, String tenantDomain, String name)
        {
            this.userName = userName;
            this.tenantDomain = tenantDomain;
            this.name = name;
        }

        public void process(final NodeRef person) throws Throwable
        {
            // note: runAs before runAsTenant (to avoid clearing tenant context, if no previous auth)
            AuthenticationUtil.runAs(new RunAsWork<Object>() {
                @Override
                public Object doWork() throws Exception
                {
                    return TenantUtil.runAsTenant(new TenantRunAsWork<Void>() {
                        public Void doWork() throws Exception
                        {
                            RunAsWorker.this.doWork(person);
                            return null;
                        }
                    }, tenantDomain);
                }
            }, userName);
        }

        public abstract void doWork(NodeRef person) throws Exception;

        public String getName()
        {
            return name;
        }
    };

    // Obtains home folder provider and path information with call backs.
    private abstract class HomeFolderHandler
    {
        protected final NodeRef person;
        protected final String overrideProviderName;

        protected NodeRef homeFolder;
        protected String userName;
        protected String originalProviderName;
        protected String providerName;
        protected HomeFolderProvider2 provider;
        protected NodeRef root;
        protected List<String> preferredPath;
        protected List<String> actualPath;
        protected NodeRef originalRoot;

        public HomeFolderHandler(NodeRef person, String overrideProviderName)
        {
            this.person = person;
            this.overrideProviderName = (overrideProviderName == null || overrideProviderName.trim().isEmpty())
                    ? null
                    : overrideProviderName;
        }

        public void doWork()
        {
            homeFolder = DefaultTypeConverter.INSTANCE.convert(NodeRef.class,
                    nodeService.getProperty(person, ContentModel.PROP_HOMEFOLDER));
            userName = DefaultTypeConverter.INSTANCE.convert(
                    String.class, nodeService.getProperty(person, ContentModel.PROP_USERNAME));

            if (homeFolder != null)
            {
                originalProviderName = DefaultTypeConverter.INSTANCE.convert(String.class,
                        nodeService.getProperty(person, ContentModel.PROP_HOME_FOLDER_PROVIDER));
                if (!BOOTSTRAP_HOME_FOLDER_PROVIDER.equals(originalProviderName) &&
                        !GUEST_HOME_FOLDER_PROVIDER.equals(originalProviderName))
                {
                    providerName = overrideProviderName != null
                            ? overrideProviderName
                            : originalProviderName;
                    provider = homeFolderManager.getHomeFolderProvider2(providerName);

                    if (provider != null)
                    {
                        root = homeFolderManager.getRootPathNodeRef(provider);
                        preferredPath = provider.getHomeFolderPath(person);

                        if (preferredPath == null || preferredPath.isEmpty())
                        {
                            handleSharedHomeProvider();
                        }
                        else
                        {
                            originalRoot = null;
                            HomeFolderProvider2 originalProvider = homeFolderManager.getHomeFolderProvider2(originalProviderName);
                            List<String> originalPreferredPath = null;
                            if (originalProvider != null)
                            {
                                originalRoot = homeFolderManager.getRootPathNodeRef(originalProvider);
                                originalPreferredPath = originalProvider.getHomeFolderPath(person);
                            }

                            if (originalProvider != null &&
                                    (originalPreferredPath == null || originalPreferredPath.isEmpty()))
                            {
                                handleOriginalSharedHomeProvider();
                            }
                            else
                            {
                                actualPath = getRelativePath(root, homeFolder);

                                if (actualPath != null && actualPath.isEmpty())
                                {
                                    handleRootOrAbove();
                                }
                                else if (preferredPath.equals(actualPath))
                                {
                                    handleInPreferredLocation();
                                }
                                else
                                {
                                    handleNotInPreferredLocation();
                                }
                            }
                        }
                    }
                    else
                    {
                        handleNotAHomeFolderProvider2();
                    }
                }
                else
                {
                    handleSpecialHomeFolderProvider();
                }
            }
            else
            {
                handleHomeFolderNotSet();
            }
        }

        protected abstract void handleInPreferredLocation();

        protected abstract void handleNotInPreferredLocation();

        protected void handleSharedHomeProvider()
        {}

        protected void handleOriginalSharedHomeProvider()
        {}

        protected void handleRootOrAbove()
        {}

        protected void handleNotAHomeFolderProvider2()
        {}

        protected void handleSpecialHomeFolderProvider()
        {}

        protected void handleHomeFolderNotSet()
        {}
    }

    // Records the parents of the preferred folder paths (the leaf folder are not recorded)
    // and checks actual paths against these.
    private class ParentFolderStructure
    {
        // Parent folders within each root node
        private Map<NodeRef, RootFolder> folders = new HashMap<NodeRef, RootFolder>();

        public void recordParentFolder(NodeRef root, List<String> path)
        {
            RootFolder rootsFolders = getFolders(root);
            synchronized (rootsFolders)
            {
                rootsFolders.add(path);
            }
        }

        /**
         * Checks to see if there is a clash between the preferred paths and the existing folder structure. If there is a clash, the existing home folder (the leaf folder) is moved to a temporary structure. This allows any parent folders to be tidied up (if empty), so that the new preferred structure can be recreated.
         * <p>
         * 
         * 1. There is no clash if the path is null or empty.
         * 
         * 2. There is a clash if there is a parent structure included the root folder itself.
         * <p>
         * 
         * 3. There is a clash if the existing path exists in the parent structure. This comparison ignores case as Alfresco does not allow duplicates regardless of case.
         * <p>
         *
         * 4. There is a clash if any of the folders in the existing path don't match the case of the parent folders.
         * 
         * 5. There is a clash there are different case versions of the parent folders themselves or other existing folders.
         * 
         * When 4 takes place, we will end up with the first one we try to recreate being used for all.
         */
        public boolean clash(NodeRef root, List<String> path)
        {
            if (path == null || path.isEmpty())
            {
                return false;
            }

            RootFolder rootsFolders = getFolders(root);
            synchronized (rootsFolders)
            {
                return rootsFolders.clash(path);
            }
        }

        private RootFolder getFolders(NodeRef root)
        {
            synchronized (folders)
            {
                RootFolder rootsFolders = folders.get(root);
                if (rootsFolders == null)
                {
                    rootsFolders = new RootFolder();
                    folders.put(root, rootsFolders);
                }
                return rootsFolders;
            }
        }

        // Records the parents of the preferred folder paths (the leaf folder are not recorded)
        // and checks actual paths against these BUT only for a single root.
        private class RootFolder extends Folder
        {
            private boolean includesRoot;

            public RootFolder()
            {
                super(null);
            }

            // Adds a path (but not the leaf folder) if it does not already exist.
            public void add(List<String> path)
            {
                if (!includesRoot)
                {
                    int parentSize = path.size() - 1;
                    if (parentSize == 0)
                    {
                        includesRoot = true;
                        children = null; // can discard children as all home folders now clash.
                        if (logger.isInfoEnabled())
                        {
                            logger.info("   # Recorded root as parent - no need to record other parents as all home folders will clash");
                        }
                    }
                    else
                    {
                        add(path, 0);
                    }
                }
            }

            /**
             * See description of {@link ParentFolderStructure#clash(NodeRef, List)}.
             * <p>
             * 
             * Performs check 2 and then calls {@link Folder#clash(List, int)} to perform 3, 4 and 5.
             */
            public boolean clash(List<String> path)
            {
                // Checks 2.
                return includesRoot ? false : clash(path, 0);
            }
        }

        private class Folder
        {
            // Case specific name of first folder added.
            String name;

            // Indicates if there is another preferred name that used different case.
            boolean duplicateWithDifferentCase;

            List<Folder> children;

            public Folder(String name)
            {
                this.name = name;
            }

            /**
             * Adds a path (but not the leaf folder) if it does not already exist.
             * 
             * @param path
             *            the full path to add
             * @param depth
             *            the current depth into the path starting with 0.
             */
            protected void add(List<String> path, int depth)
            {
                int parentSize = path.size() - 1;
                String name = path.get(depth);
                Folder child = getChild(name);
                if (child == null)
                {
                    child = new Folder(name);
                    if (children == null)
                    {
                        children = new LinkedList<Folder>();
                    }
                    children.add(child);
                    if (logger.isInfoEnabled())
                    {
                        logger.info("     " + toPath(path, depth));
                    }
                }
                else if (!child.name.equals(name))
                {
                    child.duplicateWithDifferentCase = true;
                }

                // Don't add the leaf folder
                if (++depth < parentSize)
                {
                    add(path, depth);
                }
            }

            /**
             * See description of {@link ParentFolderStructure#clash(NodeRef, List)}.
             * <p>
             * 
             * Performs checks 3, 4 and 5 for a single level and then recursively checks lower levels.
             */
            protected boolean clash(List<String> path, int depth)
            {
                String name = path.get(depth);
                Folder child = getChild(name); // Uses equalsIgnoreCase
                if (child == null)
                {
                    // Negation of check 3.
                    return false;
                }
                else if (child.duplicateWithDifferentCase) // if there folders using different case!
                {
                    // Check 5.
                    return true;
                }
                else if (!child.name.equals(name)) // if the case does not match
                {
                    // Check 4.
                    child.duplicateWithDifferentCase = true;
                    return true;
                }

                // If a match (including case) has been made to the end of the path
                if (++depth == path.size())
                {
                    // Check 3.
                    return true;
                }

                // Check lower levels.
                return clash(path, depth);
            }

            /**
             * Returns the child folder with the specified name (ignores case).
             */
            private Folder getChild(String name)
            {
                if (children != null)
                {
                    for (Folder child : children)
                    {
                        if (name.equalsIgnoreCase(child.name))
                        {
                            return child;
                        }
                    }
                }
                return null;
            }
        }
    }
}
